o/c/c/ntp: integrate NTP configuration options into snapd - #17022
o/c/c/ntp: integrate NTP configuration options into snapd#17022lorenzo-medici wants to merge 27 commits into
Conversation
|
Thu Jul 2 14:32:12 UTC 2026 No spread failures reported |
|
I changed the PR name after pushing so the static checks failed because of it and nothing else was run after that. Edit: the new test run didn't pick up the updated PR name. I force-pushed with an identical commit with different date to trigger the complete workflow. |
3f858d7 to
ef6b868
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #17022 +/- ##
==========================================
- Coverage 79.17% 79.15% -0.03%
==========================================
Files 1377 1384 +7
Lines 193225 194801 +1576
Branches 2466 2466
==========================================
+ Hits 152989 154186 +1197
- Misses 31056 31364 +308
- Partials 9180 9251 +71
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR introduces core.system.ntp.* configuration options for Ubuntu Core systems and wires them into configcore so snapd can read/write systemd-timesyncd settings (via /etc/systemd/timesyncd.conf) and apply changes.
Changes:
- Add a new
configcoreNTP module that validates NTP options, serializes them to a systemd-style config, and exposes the current system configuration via an external config getter. - Add unit tests covering valid/invalid configurations and error paths.
- Register the new NTP handler in
configcorefilesystem-only handlers.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 18 comments.
| File | Description |
|---|---|
| overlord/configstate/configcore/ntp.go | Implements NTP option validation, (de)serialization, external config getter, and application logic (write config + reload/restart timesyncd). |
| overlord/configstate/configcore/ntp_test.go | Adds gocheck tests for validation, file content updates, and error handling. |
| overlord/configstate/configcore/handlers.go | Registers the new system.ntp filesystem-only handler. |
pedronis
left a comment
There was a problem hiding this comment.
we need to be consistent with other duration options
| return fmt.Errorf("%v: %v", key, err) | ||
| } | ||
|
|
||
| timespanUs, err := validateSystemdTimeSpanFormat(span) |
There was a problem hiding this comment.
we use golang durations in other system options, we need to be consistent with that here, and also find namings that don't have "-sec" in the option names
There was a problem hiding this comment.
These values end up being written to /etc/systemd/timesyncd.conf, and should be in systemd.time format.
Regarding the option names, they are a snap-friendly formatting of the timesyncd config names. I have renamed the options to slightly clearer values:
var timesyncdToSnapKeyMapping = map[string]string{
"NTP": "servers",
"FallbackNTP": "fallback-servers",
"RootDistanceMaxSec": "max-root-time-distance",
"PollIntervalMinSec": "min-poll-interval",
"PollIntervalMaxSec": "max-poll-interval",
"ConnectionRetrySec": "connection-retry-interval",
"SaveIntervalSec": "save-interval",
}
Let me know if some need to be changed. I will push with the next set of commits.
[1] man timesyncd.conf
[2] man systemd.time
There was a problem hiding this comment.
they can't be because we don't use that format anywhere else, you need convert from go duration to what you need there
There was a problem hiding this comment.
Oh I understand now, you mean for the input values that the user set's and get's.
I will add the translation steps.
ba3b38a to
c01a6af
Compare
|
A dependency I needed was removed from go.mod in master, making the CI fail to build. I rebased the branch and added the module back, so I needed to force-push. Edit: and again |
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…l, etc. Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…ng improvements, bugfixes Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…onfiguration Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…keys in config file, expand test coverage Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…r list parsing Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…e operations Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…clude all of them Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…gs to go time.Duration strings Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
72fad8b to
8d229f4
Compare
| } | ||
|
|
||
| // Too simplistic? | ||
| var validHostname = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`).MatchString |
There was a problem hiding this comment.
Is that something that will delay start of snapd? I think @bboozzoo was looking into improving start time, but not sure.
There was a problem hiding this comment.
[a-zA-Z0-9\-] -> [a-zA-Z0-9-] should work.
There was a problem hiding this comment.
Does it really have to be a host name? ipv6 address are not matched by it.
| var validIPv4 = net.ParseIP | ||
|
|
||
| // Match the line containing "μs:" or "us:" and capture the following digits | ||
| var timespanUsRegexp = regexp.MustCompile(`(?:μs|us):\s*(\d+)`) |
There was a problem hiding this comment.
Non-ascii? Could we use "us" instead?
There was a problem hiding this comment.
Verified on UC24:
# systemd-analyze timespan 1d
Original: 1d
μs: 86400000000
Human: 1d
| } | ||
|
|
||
| func validateServerName(serverAddress string) error { | ||
| if validIPv4(serverAddress) == nil && !validHostname(serverAddress) { |
There was a problem hiding this comment.
what if my NTP server is accessible over IPv6?
|
|
||
| // Too simplistic? | ||
| var validHostname = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`).MatchString | ||
| var validIPv4 = net.ParseIP |
There was a problem hiding this comment.
this actually parses ipv6 as well and there's no need to use a variable
| // Validation for user submitted values has already been done, so ParseDuration cannot fail here. | ||
| if minVal, exists := ntpCfg["min-poll-interval"]; exists { | ||
| pollIntervalMinString = minVal.(string) | ||
| pollIntervalMin, _ = time.ParseDuration(strings.TrimSpace(pollIntervalMinString)) |
There was a problem hiding this comment.
| pollIntervalMin, _ = time.ParseDuration(strings.TrimSpace(pollIntervalMinString)) | |
| pollIntervalMin, _ = time.ParseDuration(pollIntervalMinString) |
| } | ||
| if maxVal, exists := ntpCfg["max-poll-interval"]; exists { | ||
| pollIntervalMaxString = maxVal.(string) | ||
| pollIntervalMax, _ = time.ParseDuration(strings.TrimSpace(pollIntervalMaxString)) |
There was a problem hiding this comment.
| pollIntervalMax, _ = time.ParseDuration(strings.TrimSpace(pollIntervalMaxString)) | |
| pollIntervalMax, _ = time.ParseDuration(pollIntervalMaxString) |
|
|
||
| // The value that the user inputs should be parsed as a Go duration string for consistency | ||
| // with other configuration options | ||
| duration, err := time.ParseDuration(strings.TrimSpace(valueStr)) |
There was a problem hiding this comment.
| duration, err := time.ParseDuration(strings.TrimSpace(valueStr)) | |
| duration, err := time.ParseDuration(valueStr) |
There was a problem hiding this comment.
side note, it's probably worth checking that the input does not contain extra whitespace
| return byteStream | ||
| } | ||
|
|
||
| func getNTPFromSystemHelper(key string) (result any, err error) { |
There was a problem hiding this comment.
I followed the flow from the netplan config, which also ignores the key.
|
|
||
| func (s *ntpSuite) TestNTPSetRestartDaemonError(c *C) { | ||
| r := systemd.MockSystemctl(func(cmd ...string) ([]byte, error) { | ||
| s.sysdLog = append(s.sysdLog, cmd) |
| func (s *ntpSuite) SetUpTest(c *C) { | ||
| s.configcoreSuite.SetUpTest(c) | ||
|
|
||
| c.Assert(os.MkdirAll(filepath.Join(dirs.GlobalRootDir, "etc/systemd"), 0755), IsNil) |
There was a problem hiding this comment.
this is done again just below
| c.Assert(os.MkdirAll(filepath.Join(dirs.GlobalRootDir, "etc/systemd"), 0755), IsNil) |
| for i, test := range getConfigurationTests { | ||
| // Apply the config | ||
| conf := configcore.PlainCoreConfig(test.newConfig) | ||
| err := configcore.FilesystemOnlyRun(core24Dev, conf) |
There was a problem hiding this comment.
won't this call systemd-analyze?
There was a problem hiding this comment.
It will. The existing configuration is read to identify changes and the need to restart the timesyncd daemon. In the reading logic in getNTPFromSystem, systemd-analyze is used to convert systemd timespans into go Durations.
For this reason, I also cannot add the "exit 1" snippet mocking systemd-analyze.
The error paths are already covered, but I can mock it if you think it best.
| // We are on Core | ||
| restore := release.MockOnClassic(false) | ||
| s.AddCleanup(restore) | ||
| } |
There was a problem hiding this comment.
can you add a snippet like:
cmd = testutil.MockCommand(c, "systemd-analyze", "echo 'systemd-analyze not mocked'; exit 1")
s.AddCleanup(cmd.Restore()
* replaced `\-` with `-` in regex for server address validation * removed `validIPv4` variable and directly used `net.ParseIP` as it supports IPv6 * added test with IPv6 server being validated. Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…r trailing whitespace Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Meulengracht
left a comment
There was a problem hiding this comment.
First pass, some comments also this should have a spread test.
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…Helper Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…nap configs Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
| [ "$ts_before" != "$(get_restart_ts)" ] | ||
|
|
||
| echo "snap get reports no configuration option again after the reset" | ||
| not snap get system system.ntp No newline at end of file |
| echo "Updating a single option preserves the other configured options" | ||
| ts_before="$(get_restart_ts)" | ||
| snap set system system.ntp.min-poll-interval=1m | ||
| MATCH '^PollIntervalMinSec=1m$' < "$NTP_CONF" | ||
| MATCH '^NTP=192.168.1.1 ntp.ubuntu.com$' < "$NTP_CONF" | ||
| MATCH '^SaveIntervalSec=20s$' < "$NTP_CONF" | ||
| [ "$ts_before" != "$(get_restart_ts)" ] |
There was a problem hiding this comment.
I think this should atleast assert one additional non updated key, maybe FallbackNTP to ensure that we actually preserve the field correctly.
| unitOptions = append(unitOptions, &unitOption) | ||
| } | ||
|
|
||
| byteStream, _ := io.ReadAll(unit.Serialize(unitOptions)) |
There was a problem hiding this comment.
Needs a comment on why we are ignoring the error here
| return nil, nil | ||
| } | ||
|
|
||
| file, err := os.Open(filepath.Join(dirs.GlobalRootDir, "etc", "systemd", "timesyncd.conf")) |
There was a problem hiding this comment.
If we decide to ignore drop-ins, but we need to document this behaviour, both here in the code but also as a part of the forward-facing docs - but since this is core the presence of drop ins can be relatively limited, is there a requirement on this behaviour from what prompted this feature? @lorenzo-medici
…sful set operation Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
…d not using drop-ins for timesyncd configuration Signed-off-by: Lorenzo Medici <lorenzo.medici@canonical.com>
This is still a draft. For example, tests are missing.
This PR adds a set of configuration options to the
coresnap to allow control over the NTP utilities:These map to the
systemd-timesyncd.serviceconfiguration keys in/etc/systemd/timesyncd.confkeys like so:The configuration is stored on disk. Whenever it is retrieved, it is read from the file, parsed and transformed into a JSON-like object.
When it is set, the entries are validated and the approved configuration is stored on disk. The service is then reloaded.
The keys are validated for format and values. The two
serverskeys must be a list of strings, whose items must be valid IP addresses or network names.The other keys must be valid systemd timespans or positive integers (defaulting to seconds). Additionally,
poll-interval-min-secmust be greater than16sand lower thanpoll-interval-max-sec, andconnection-retry-secmust be greater than1s.This has been tested manually in a VM and the operations validated by checking the contents of
/etc/systemd/timesyncd.confand the output oftimedatectl show-timesync. Unit tests are still pending.References: